Embedding 유사도만으로 검색하면 부족한 이유

Embedding 유사도만으로 검색하면 부족한 이유

한눈에 보기

Dense embedding은 표현이 달라도 의미가 비슷한 passage를 찾는 데 강하다. 그러나 하나의 vector로 문서 전체 의미를 압축하기 때문에 희귀한 오류 코드, 정확한 제품명, 숫자와 부정 조건이 검색 점수에 충분히 반영되지 않을 수 있다. Cosine similarity는 관련성의 확률도 아니고 안전한 전역 threshold도 아니다. Query 유형별로 dense, keyword, metadata filter와 reranker를 조합하고 실제 근거 span으로 평가해야 한다.

목차

의미가 비슷하다는 것과 정답인 것은 다르다

사용자가 운영 문서에서 다음 오류를 검색한다고 하자.

Query: PAY-1042가 발생하면 재시도해도 되나요?

Vector 검색 결과가 이렇게 나올 수 있다.

1. 결제 승인 timeout 시 재시도 정책          cosine 0.87
2. PAY-1041 카드 인증 실패 처리             cosine 0.84
3. PAY-1042 중복 승인 감지: 재시도 금지      cosine 0.82

일반적인 의미로는 1번 문서가 query와 매우 비슷하다. 하지만 질문의 정확한 오류 코드에 답하는 근거는 3번이다. RAG가 top-2만 model에 제공하면 자연스럽지만 틀린 답을 만들 수 있다.

flowchart LR
    Q[PAY-1042 재시도?] --> D[Dense Similarity]
    D --> A[결제 timeout 재시도]
    D --> B[PAY-1041 실패]
    D -. 순위 밖 .-> C[PAY-1042 재시도 금지]

Embedding 검색이 실패한 이유는 model이 나빠서만은 아니다. Dense retrieval의 목표는 query와 passage를 vector 공간에서 가깝게 두는 것이다. 업무상 정확한 code 일치, 최신 문서, 접근 권한과 예외 규칙은 별도 신호일 수 있다.

검색의 질문

“이 문장과 의미가 비슷한가?”와 “사용자가 묻는 구체적인 사실의 권위 있는 근거인가?”는 같은 질문이 아니다.

Embedding 검색이 하는 일

Dual encoder 방식에서는 query와 document를 각각 vector로 변환한다.

q = encodeQuery("계정이 잠겼을 때 푸는 방법")
d = encodeDocument("로그인 실패 횟수를 초기화하는 절차")

score(q, d) = cosineSimilarity(q, d)

Cosine similarity는 두 vector의 방향이 얼마나 가까운지 계산한다.

cos(q, d) = (q · d) / (||q|| ||d||)
function cosineSimilarity(left: number[], right: number[]): number {
  const dot = left.reduce((sum, value, index) => sum + value * right[index], 0);
  const leftNorm = Math.sqrt(left.reduce((sum, value) => sum + value ** 2, 0));
  const rightNorm = Math.sqrt(right.reduce((sum, value) => sum + value ** 2, 0));
  return dot / (leftNorm * rightNorm);
}

실제 vector DB의 거리 metric과 model 권장 normalization을 확인해야 한다. Dot product, cosine과 Euclidean distance의 점수는 서로 직접 비교할 수 없다.

Dense Passage Retrieval 연구는 question과 passage를 별도 encoder로 표현하고 maximum inner product search로 후보 passage를 찾는 방식이 open-domain QA에서 강력할 수 있음을 보여 줬다. 다만 특정 학습·평가 조건의 성능이 모든 사내 문서와 query 유형에 그대로 적용된다는 뜻은 아니다.

하나의 Vector에 정보를 압축할 때 생기는 손실

Passage가 가진 token은 수백 개지만 일반적인 dense retriever는 이를 하나의 고정 길이 vector로 압축한다.

문서 내용
  제품 = Enterprise
  version = 4.2
  error = PAY-1042
  condition = SSO가 아닌 계정
  action = 재시도 금지
  exception = 관리자 수동 확인 뒤 허용

                ↓ encode

vector = [0.018, -0.224, 0.091, ...]

Vector는 전체 의미를 표현하지만 각 token이 독립적인 필드처럼 보존되는 것은 아니다. Passage가 길고 여러 topic을 담을수록 일부 세부 신호가 희석될 수 있다.

다음 두 문서는 많은 단어와 주제를 공유한다.

A: PAY-1042 오류에서는 자동 재시도를 허용한다.
B: PAY-1042 오류에서는 자동 재시도를 허용하지 않는다.

두 문장은 않는다 하나로 업무 의미가 반대지만 lexical과 semantic representation의 대부분은 같다. Dense similarity만으로 둘의 권위와 참·거짓을 결정할 수 없다.

Vector DB는 지식의 판정기가 아니다

가장 가까운 이웃을 찾을 뿐, 문서가 최신인지, 접근 가능한지, 서로 모순되는지, 질문에 필요한 모든 조건을 포함했는지는 별도 pipeline이 확인해야 한다.

오류 코드와 식별자는 Exact Match가 중요하다

운영과 개발 문서에는 의미로 바꾸면 안 되는 token이 많다.

Embedding tokenizer가 이런 문자열을 여러 subtoken으로 나누면 비슷한 code끼리 충분히 구분하지 못할 수 있다.

PAY-1042
PAY-1041
PAY-1402

사람에게는 다른 식별자지만 주변 문맥이 같으면 vector가 가깝게 나올 수 있다. 이때 inverted index의 exact term match가 더 직접적인 신호다.

const exactSignals = query.match(
  /\b(?:[A-Z]{2,10}-\d{2,}|[A-Z_]{4,}|\w+V\d+)\b/g,
) ?? [];

정규식은 예시다. 실제로는 domain별 error code, UUID, path와 symbol parser가 필요하다. 추출한 exact token을 keyword query의 must 또는 강한 boost에 넣고 dense 후보와 합친다.

{
  "must": [
    { "term": { "error_codes.keyword": "PAY-1042" } }
  ],
  "should": [
    { "match": { "body": "재시도해도 되나요" } }
  ]
}

숫자와 날짜와 Version은 가까움으로 비교할 수 없다

Embedding은 숫자의 대소 관계나 정확한 equality를 database predicate처럼 보장하지 않는다.

보존 기간은 30일이다.
보존 기간은 90일이다.
보존 기간은 365일이다.

세 문장은 의미 공간에서 모두 매우 비슷할 수 있다. Query가 “Pro 요금제의 보존 기간이 90일인가?”라면 숫자와 요금제의 관계가 정확해야 한다.

날짜도 마찬가지다.

2025-12-31 이전 정책
2026-01-01 이후 정책

최신 문서 filter 없이 dense score만 비교하면 표현이 더 비슷한 구버전이 위에 올 수 있다. 숫자, 날짜와 version은 structured metadata로 저장한다.

type RetrievalFilter = {
  product?: string;
  effectiveAt?: string;
  version?: string;
  language?: string;
  tenantId?: string;
};
WHERE product = :product
  AND effective_from <= :question_time
  AND (effective_to IS NULL OR effective_to > :question_time)

범위 조건은 vector similarity 뒤에 model에게 판단시키기보다 후보 생성 전에 강제하는 편이 안전하다.

부정과 예외 조건이 뒤집힐 수 있다

다음 표현은 keyword와 topic이 거의 같다.

관리자는 승인을 받아야 한다.
관리자는 승인을 받을 필요가 없다.
관리자만 승인을 생략할 수 있다.
관리자도 긴급 변경에서는 승인을 생략할 수 없다.

Query가 넓으면 여러 문서가 함께 검색될 수 있다. Generator가 최신성과 적용 범위를 모르고 문장을 섞으면 잘못된 일반화를 한다.

대응은 단순히 chunk를 작게 만드는 것 이상이다.

  1. 기본 규칙과 예외를 같은 parent section으로 연결한다.
  2. 정책 version과 효력 기간을 metadata로 filter한다.
  3. Reranker가 query-token과 passage-token 관계를 세밀하게 평가하게 한다.
  4. 서로 모순되는 근거가 검색되면 답변 전에 conflict 상태로 표시한다.
  5. 인용에 rule ID와 적용 대상을 포함한다.
type PolicyEvidence = {
  ruleId: string;
  appliesTo: string[];
  statement: string;
  exceptionTo?: string;
  effectiveFrom: string;
  effectiveTo?: string;
};

고유명사와 희귀 용어의 Domain Gap

Embedding model이 학습할 때 보지 못했거나 드물게 본 내부 제품명과 약어는 표현이 불안정할 수 있다.

베베스냅 촬영권
ZORP reconciliation
MINT-Bridge mode

내부에서는 구체적인 의미가 있지만 일반 model은 철자 형태나 주변 단어에 의존한다. 문서 corpus가 한국어인데 model이 주로 영어 retrieval로 평가되었다면 domain과 언어 차이도 생긴다.

대응 방법은 여러 층이 있다.

{
  "entityId": "product-bebesnap",
  "canonicalName": "베베스냅",
  "aliases": ["BebeSnap", "베베 스냅"],
  "type": "product"
}

Query expansion은 원문 의도를 바꿀 수 있으므로 원 query, 확장 term과 적용 version을 로그에 남긴다.

질문과 문서 표현이 다를 때의 강점

Dense retrieval을 버려야 한다는 뜻은 아니다. Keyword가 놓치는 표현 차이를 잘 연결한다.

Query: 비밀번호를 여러 번 틀려서 접속이 막혔어요.
Document: 연속 인증 실패에 따른 계정 잠금 해제 절차

두 문장은 exact term이 많이 겹치지 않지만 의미가 가깝다. 다음 경우에도 dense 신호가 유용하다.

Dense의 강점: 표현이 달라도 의미 연결
Lexical의 강점: 정확한 token과 희귀 term 보존
Metadata의 강점: 구조화된 범위와 권한 강제
Reranker의 강점: 후보를 query와 함께 정밀 비교

따라서 dense와 sparse 중 하나를 선택하는 이분법보다 각 신호의 역할을 분리하는 편이 낫다.

Cosine 점수를 확률처럼 해석하지 않는다

cosine > 0.8이면 80% 확률로 관련 있다고 해석하면 안 된다. 점수 분포는 model, corpus, query 길이, normalization과 language에 따라 달라진다.

Corpus A의 관련 문서 점수: 0.62 ~ 0.78
Corpus B의 무관 문서 점수: 0.80 ~ 0.91

가능한 이유는 corpus B 문서가 서로 매우 유사한 template을 공유하기 때문이다. 전역 threshold 하나는 두 corpus에 맞지 않는다.

점수를 사용할 때는 다음을 구분한다.

방식 의미
Top-k 상대적으로 가까운 k개
Absolute threshold 학습·보정한 범위 아래 제거
Margin 1위와 다음 후보의 점수 차이
Calibrated probability label로 별도 보정한 관련 확률
type DenseDecision = {
  topScore: number;
  secondScore?: number;
  thresholdVersion: string;
  corpus: string;
};

function shouldAbstain(decision: DenseDecision): boolean {
  const margin = decision.secondScore === undefined
    ? 1
    : decision.topScore - decision.secondScore;

  return decision.topScore < thresholdFor(decision.corpus) || margin < 0.01;
}

이 함수의 숫자도 설명용이다. 실제 labeled query에서 precision-recall trade-off를 보고 corpus와 query class별로 정한다. 점수가 낮다고 답이 없다는 뜻도 아니므로 lexical 결과와 함께 판단한다.

Metadata Filter를 검색 뒤에 적용하면 늦다

Vector DB에서 전체 top-10을 뽑은 뒤 권한 없는 문서를 제거한다고 하자.

Top-10 dense result
  8개: 다른 tenant의 매우 유사한 문서 -> 제거
  2개: 현재 tenant 문서

현재 tenant의 11위 이하 문서는 아예 후보에 들어오지 않는다. Filter는 가능하면 ANN 검색 자체에 적용하고, engine이 pre-filter인지 post-filter인지 확인한다.

const results = await vectorIndex.search({
  queryVector,
  topK: 20,
  filter: {
    tenantId: authenticatedTenantId,
    accessGroups: { anyOf: userGroups },
    effectiveAt: questionTime,
  },
});

권한 filter는 품질 최적화가 아니라 보안 경계다. Model이 검색된 unauthorized text를 보지 않게 retrieval layer에서 강제한다.

Filter cardinality가 매우 낮으면 ANN index 성능과 recall이 달라질 수 있다. Tenant별 index 분리, partition과 oversampling을 실제 분포에서 평가한다.

Chunk 크기와 Embedding 품질은 연결된다

RAG에서 Chunk 크기를 정하는 기준에서 다룬 것처럼 긴 chunk는 여러 topic을 하나의 vector에 섞고, 너무 작은 chunk는 주어와 예외를 잃는다.

작은 chunk
  dense representation은 선명
  그러나 context가 불완전

큰 chunk
  context는 풍부
  그러나 single-vector compression이 강함

검색 child와 반환 parent를 분리하면 dense retrieval의 좁은 의미 matching과 generation의 넓은 문맥을 함께 얻을 수 있다.

Embedding할 text에 heading path를 넣는 방법도 있다.

문서: 결제 오류 정책
섹션: PAY-1042 > 중복 승인 > 재시도

자동 재시도하지 않고 operation ID로 기존 승인 결과를 조회한다.

다만 반복 heading이 점수를 지배할 수 있으므로 포함 여부를 평가한다. Chunking, metadata와 embedding은 독립적으로 고를 수 있는 설정이 아니다.

Query를 유형별로 분류한다

모든 query를 같은 dense top-k로 처리하지 않고 신호가 중요한 유형을 감지할 수 있다.

type QueryFeatures = {
  exactTokens: string[];
  quotedPhrases: string[];
  numbers: string[];
  dateRange?: { from?: string; to?: string };
  entities: string[];
  isNaturalLanguageQuestion: boolean;
};
type RetrievalRoute =
  | "DENSE_FIRST"
  | "LEXICAL_FIRST"
  | "HYBRID"
  | "STRUCTURED_LOOKUP";

function chooseRoute(features: QueryFeatures): RetrievalRoute {
  if (features.exactTokens.length > 0 || features.quotedPhrases.length > 0) {
    return "LEXICAL_FIRST";
  }
  if (features.numbers.length > 0 || features.dateRange) {
    return "HYBRID";
  }
  if (features.entities.length === 1 && !features.isNaturalLanguageQuestion) {
    return "STRUCTURED_LOOKUP";
  }
  return "DENSE_FIRST";
}

Rule-based classifier로 시작하고 실패 데이터를 모아 개선할 수 있다. LLM으로 route를 고를 경우에도 exact token extractor와 권한 filter는 deterministic하게 유지한다.

Route가 달라도 공통 candidate schema로 합치면 이후 reranker를 재사용할 수 있다.

type Candidate = {
  chunkId: string;
  denseScore?: number;
  lexicalScore?: number;
  exactMatches: string[];
  sourceVersion: string;
  retrievedBy: string[];
};

재구성한 Dense 검색 예제

다음은 vector 검색의 최소 구조를 보여 주는 가상 TypeScript 예시다.

type DenseSearchInput = {
  query: string;
  tenantId: string;
  effectiveAt: string;
  topK: number;
};

async function denseSearch(input: DenseSearchInput): Promise<Candidate[]> {
  const queryVector = await embeddings.encodeQuery(input.query);

  const hits = await vectorIndex.search({
    vector: queryVector,
    topK: input.topK,
    filter: {
      tenantId: input.tenantId,
      effectiveFrom: { lte: input.effectiveAt },
      effectiveTo: { gtOrMissing: input.effectiveAt },
    },
  });

  return hits.map((hit) => ({
    chunkId: hit.id,
    denseScore: hit.score,
    exactMatches: [],
    sourceVersion: hit.metadata.sourceVersion,
    retrievedBy: ["dense"],
  }));
}

이 구현은 query와 document가 사용 중인 embedding model의 권장 입력 prefix, normalization과 metric을 따른다는 전제가 필요하다. Model version도 index manifest에 기록한다.

{
  "indexVersion": "knowledge-v12",
  "embeddingModel": "example-embed-v3",
  "embeddingDimension": 1024,
  "metric": "cosine",
  "normalized": true,
  "chunkPolicy": "structural-400-v3"
}

Index와 query encoder version이 다르면 같은 vector 공간이라는 보장이 없다. Rolling migration 때 새 index를 만들고 shadow evaluation 뒤 전환한다.

Dense 결과의 실패 이유를 기록한다

검색 실패를 “RAG가 틀림”으로만 남기면 개선할 수 없다. Failure taxonomy를 만든다.

type RetrievalFailure =
  | "EXACT_TOKEN_MISSED"
  | "WRONG_ENTITY"
  | "WRONG_VERSION"
  | "NEGATION_REVERSED"
  | "EVIDENCE_SPLIT"
  | "DOMAIN_TERM_UNKNOWN"
  | "FILTER_REMOVED_ALL"
  | "ANSWER_NOT_IN_CORPUS"
  | "RANKED_BELOW_K";

예를 들어 정답 chunk가 dense rank 18이지만 lexical rank 1이라면 EXACT_TOKEN_MISSED로 볼 수 있다. 정답 chunk가 index에 없다면 retriever 문제가 아니라 ingestion 문제다.

검색 실패 조사 순서
1. 정답 문서가 corpus에 존재하는가
2. Parser가 정답 span을 보존했는가
3. Chunk가 index에 들어갔는가
4. Metadata filter가 의도대로 동작했는가
5. Candidate에 들어왔지만 rank가 낮았는가
6. Candidate는 맞았지만 generator가 무시했는가

이 순서를 지키면 embedding model 교체로 해결되지 않는 문제를 잘못 튜닝하는 일을 줄인다.

Sparse Hybrid와 Late Interaction 선택

Dense 단독의 약점을 보완하는 대표 선택지는 세 가지다.

방식 강점 비용·주의점
BM25 등 sparse exact token, 희귀 term, 설명 가능성 paraphrase에 약함
Dense + sparse hybrid 두 candidate recall 결합 score 정규화와 중복 제거
Reranker query-document를 함께 정밀 판정 후보당 inference 비용
Late interaction token-level 상호작용 보존 index 크기와 검색 복잡도

Hybrid search의 candidate 결합과 RRF는 Hybrid Search로 키워드와 의미 검색 결합하기에서 다룬다. Candidate 수를 넓힌 뒤 Reranker를 추가할 때 얻는 것과 비용의 reranker로 상위 순서를 조정할 수 있다.

ColBERT는 passage를 하나의 vector로만 압축하는 대신 token 수준의 contextual representation과 late interaction을 이용한다. Single-vector dense보다 세밀한 matching을 제공할 수 있지만 저장량과 계산 구조가 달라진다.

BEIR benchmark는 다양한 domain에서 BM25가 강한 baseline이며 reranking과 late-interaction 방식이 평균적으로 좋은 zero-shot 성능을 보이지만 계산 비용이 크다는 결과를 보고했다. 특정 benchmark 1개의 우승 방식보다 우리 corpus의 query distribution을 평가해야 한다.

평가셋과 지표를 설계한다

Dense retrieval의 강점만 있는 paraphrase 질문으로 평가하면 lexical 약점을 발견하지 못한다. Query category를 나눈다.

categories:
  semantic_paraphrase: 100
  exact_error_code: 60
  product_identifier: 40
  number_and_version: 60
  negation_and_exception: 50
  temporal_policy: 40
  multi_evidence: 50
  no_answer: 50

숫자는 구성 예시다. 실제 검색 로그와 위험도에 맞춘다.

각 case에는 정답 답변만 아니라 evidence chunk 또는 source span을 둔다.

{
  "query": "PAY-1042는 자동 재시도해도 되나요?",
  "requiredEvidence": [
    {
      "sourceId": "payment-error-policy-v6",
      "ruleId": "PAY-1042",
      "span": [820, 1044]
    }
  ],
  "category": "exact_error_code",
  "expectedAnswer": "자동 재시도하지 않고 기존 operation 결과를 확인한다"
}

지표는 다음을 함께 본다.

전체 평균이 좋아도 exact_error_code recall이 낮으면 운영 문서 검색에는 위험하다. Category별 최소 기준을 둔다.

운영에서 관찰할 변화

검색 품질은 corpus와 query가 변하면서 달라진다. 배포 때만 평가하고 끝내지 않는다.

지표 의미
dense-only zero-result threshold가 과도하거나 domain drift
lexical-dense overlap 두 retriever가 얼마나 같은 후보를 찾는가
exact query dense miss 식별자 query의 약점
top-1 / top-2 margin 모호한 검색 증가
filter selectivity tenant·version filter 영향
stale source retrieval 최신성 metadata 문제
user reformulation rate 첫 검색 결과 불만족 신호
citation correction rate 검색 근거와 답변 불일치
embedding drift 새 model/index의 점수 분포 변화

Query 원문에는 개인정보와 secret이 포함될 수 있으므로 관찰 로그에 그대로 남기지 않는다. Category, 추출된 비민감 feature, candidate ID와 rank를 중심으로 기록하고, 필요한 sample은 접근 통제와 보존 기한을 둔다.

{
  "event": "retrieval.completed",
  "queryId": "qry-81",
  "queryClass": "EXACT_IDENTIFIER",
  "indexVersion": "knowledge-v12",
  "denseTopScore": 0.81,
  "denseLexicalOverlapAt10": 0.3,
  "exactTokenFoundInDenseTop10": false,
  "finalCandidateCount": 12,
  "elapsedMs": 74
}

마무리

Embedding 유사도 검색은 단어가 달라도 의미가 비슷한 문서를 찾게 해 주는 강력한 도구다. 그러나 검색 대상의 모든 사실을 하나의 vector에 무손실로 보존하는 database index는 아니다.

의미 유사도는 후보를 찾는 한 가지 신호이고, 정확한 token·구조화된 범위·최신성·권한과 세밀한 관련성 판단은 별도의 신호로 보완해야 한다.

실무에서는 다음 기준으로 점검할 수 있다.

  1. Error code, ID, symbol과 path query를 별도 category로 평가한다.
  2. 숫자, 날짜와 version을 metadata field로 저장한다.
  3. 부정과 예외가 있는 문서는 기본 rule과 함께 연결한다.
  4. 내부 고유명사의 canonical name과 alias를 관리한다.
  5. Cosine score를 관련 확률처럼 해석하지 않는다.
  6. Threshold를 corpus와 query class별 labeled data로 보정한다.
  7. Tenant, 권한과 효력 기간 filter를 검색 전에 강제한다.
  8. Chunk 크기와 heading prefix가 dense 표현에 미치는 영향을 평가한다.
  9. Exact feature가 있는 query는 lexical 또는 hybrid route로 보낸다.
  10. Dense, lexical과 filter 결과를 공통 candidate schema로 보존한다.
  11. 검색 실패를 ingestion, filter, candidate와 generation 단계로 분류한다.
  12. Hybrid, reranker와 late interaction의 품질·비용을 비교한다.
  13. 실제 evidence span이 있는 평가셋을 사용한다.
  14. 전체 평균뿐 아니라 위험한 query category별 최소 성능을 본다.

Dense retrieval의 한계를 이해하는 목적은 이를 빼기 위해서가 아니다. 어떤 질문에서 의미 신호가 충분하고 어떤 질문에서 정확한 문자열과 구조화된 조건이 필요한지 구분해야 서로 다른 검색 방식을 올바르게 조합할 수 있다.

참고 자료

관련 노트